@dxos/app-graph 0.8.4-main.72ec0f3 → 0.8.4-main.74a063c4e0

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 (60) hide show
  1. package/dist/lib/browser/chunk-AKBGYELG.mjs +1603 -0
  2. package/dist/lib/browser/chunk-AKBGYELG.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +29 -827
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/testing/index.mjs +39 -0
  7. package/dist/lib/browser/testing/index.mjs.map +7 -0
  8. package/dist/lib/node-esm/chunk-HR5S4XYH.mjs +1604 -0
  9. package/dist/lib/node-esm/chunk-HR5S4XYH.mjs.map +7 -0
  10. package/dist/lib/node-esm/index.mjs +29 -828
  11. package/dist/lib/node-esm/index.mjs.map +4 -4
  12. package/dist/lib/node-esm/meta.json +1 -1
  13. package/dist/lib/node-esm/testing/index.mjs +40 -0
  14. package/dist/lib/node-esm/testing/index.mjs.map +7 -0
  15. package/dist/types/src/atoms.d.ts +8 -0
  16. package/dist/types/src/atoms.d.ts.map +1 -0
  17. package/dist/types/src/graph-builder.d.ts +111 -65
  18. package/dist/types/src/graph-builder.d.ts.map +1 -1
  19. package/dist/types/src/graph.d.ts +179 -213
  20. package/dist/types/src/graph.d.ts.map +1 -1
  21. package/dist/types/src/index.d.ts +7 -3
  22. package/dist/types/src/index.d.ts.map +1 -1
  23. package/dist/types/src/node-matcher.d.ts +244 -0
  24. package/dist/types/src/node-matcher.d.ts.map +1 -0
  25. package/dist/types/src/node-matcher.test.d.ts +2 -0
  26. package/dist/types/src/node-matcher.test.d.ts.map +1 -0
  27. package/dist/types/src/node.d.ts +50 -5
  28. package/dist/types/src/node.d.ts.map +1 -1
  29. package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
  30. package/dist/types/src/testing/index.d.ts +2 -0
  31. package/dist/types/src/testing/index.d.ts.map +1 -0
  32. package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
  33. package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
  34. package/dist/types/src/util.d.ts +39 -0
  35. package/dist/types/src/util.d.ts.map +1 -0
  36. package/dist/types/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +47 -35
  38. package/src/atoms.ts +25 -0
  39. package/src/graph-builder.test.ts +995 -112
  40. package/src/graph-builder.ts +705 -284
  41. package/src/graph.test.ts +448 -120
  42. package/src/graph.ts +1019 -413
  43. package/src/index.ts +10 -3
  44. package/src/node-matcher.test.ts +301 -0
  45. package/src/node-matcher.ts +314 -0
  46. package/src/node.ts +82 -8
  47. package/src/stories/EchoGraph.stories.tsx +151 -121
  48. package/src/stories/Tree.tsx +2 -2
  49. package/src/testing/index.ts +5 -0
  50. package/src/testing/setup-graph-builder.ts +41 -0
  51. package/src/util.ts +95 -0
  52. package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
  53. package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
  54. package/dist/types/src/signals-integration.test.d.ts +0 -2
  55. package/dist/types/src/signals-integration.test.d.ts.map +0 -1
  56. package/dist/types/src/testing.d.ts +0 -5
  57. package/dist/types/src/testing.d.ts.map +0 -1
  58. package/src/experimental/graph-projections.test.ts +0 -56
  59. package/src/signals-integration.test.ts +0 -219
  60. package/src/testing.ts +0 -20
@@ -3,44 +3,538 @@
3
3
  //
4
4
 
5
5
  import { Atom, Registry } from '@effect-atom/atom-react';
6
- import { effect } from '@preact/signals-core';
7
6
  import * as Array from 'effect/Array';
7
+ import type * as Context from 'effect/Context';
8
+ import * as Effect from 'effect/Effect';
8
9
  import * as Function from 'effect/Function';
9
10
  import * as Option from 'effect/Option';
11
+ import * as Pipeable from 'effect/Pipeable';
10
12
  import * as Record from 'effect/Record';
13
+ import type * as Schema from 'effect/Schema';
14
+ import { scheduleTask, yieldOrContinue } from 'main-thread-scheduling';
11
15
 
12
- import { type CleanupFn, type MulticastObservable, type Trigger } from '@dxos/async';
16
+ import { type CleanupFn, type Trigger } from '@dxos/async';
17
+ import { type Entity, type Type } from '@dxos/echo';
13
18
  import { log } from '@dxos/log';
14
- import { type MaybePromise, type Position, byPosition, getDebugName, isNode, isNonNullable } from '@dxos/util';
19
+ import { type MaybePromise, type Position, byPosition, getDebugName, isNonNullable } from '@dxos/util';
20
+
21
+ import * as Graph from './graph';
22
+ import * as Node from './node';
23
+ import * as NodeMatcher from './node-matcher';
24
+ import {
25
+ getParentId,
26
+ nodeArgsUnchanged,
27
+ normalizeRelation,
28
+ primaryKey,
29
+ primaryParts,
30
+ qualifyId,
31
+ validateSegmentId,
32
+ } from './util';
15
33
 
16
- import { ACTION_GROUP_TYPE, ACTION_TYPE, type ExpandableGraph, Graph, type GraphParams, ROOT_ID } from './graph';
17
- import { type ActionData, type Node, type NodeArg, type Relation, actionGroupSymbol } from './node';
34
+ //
35
+ // Extension Types
36
+ //
18
37
 
19
38
  /**
20
39
  * Graph builder extension for adding nodes to the graph based on a node id.
21
40
  */
22
- export type ResolverExtension = (id: string) => Atom.Atom<NodeArg<any> | null>;
41
+ export type ResolverExtension = (id: string) => Atom.Atom<Node.NodeArg<any> | null>;
23
42
 
24
43
  /**
25
44
  * Graph builder extension for adding nodes to the graph based on a connection to an existing node.
26
45
  *
27
46
  * @param params.node The existing node the returned nodes will be connected to.
28
47
  */
29
- export type ConnectorExtension = (node: Atom.Atom<Option.Option<Node>>) => Atom.Atom<NodeArg<any>[]>;
48
+ export type ConnectorExtension = (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
30
49
 
31
50
  /**
32
51
  * Constrained case of the connector extension for more easily adding actions to the graph.
33
52
  */
34
53
  export type ActionsExtension = (
35
- node: Atom.Atom<Option.Option<Node>>,
36
- ) => Atom.Atom<Omit<NodeArg<ActionData>, 'type' | 'nodes' | 'edges'>[]>;
54
+ node: Atom.Atom<Option.Option<Node.Node>>,
55
+ ) => Atom.Atom<Omit<Node.NodeArg<Node.ActionData<any>>, 'type' | 'nodes' | 'edges'>[]>;
37
56
 
38
57
  /**
39
58
  * Constrained case of the connector extension for more easily adding action groups to the graph.
40
59
  */
41
60
  export type ActionGroupsExtension = (
42
- node: Atom.Atom<Option.Option<Node>>,
43
- ) => Atom.Atom<Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[]>;
61
+ node: Atom.Atom<Option.Option<Node.Node>>,
62
+ ) => Atom.Atom<Omit<Node.NodeArg<typeof Node.actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>[]>;
63
+
64
+ export type BuilderExtension = Readonly<{
65
+ id: string;
66
+ position: Position;
67
+ relation?: Node.RelationInput;
68
+ resolver?: ResolverExtension;
69
+ connector?: (node: Atom.Atom<Option.Option<Node.Node>>) => Atom.Atom<Node.NodeArg<any>[]>;
70
+ }>;
71
+
72
+ export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
73
+
74
+ //
75
+ // GraphBuilder Core
76
+ //
77
+
78
+ export type GraphBuilderTraverseOptions = {
79
+ visitor: (node: Node.Node, path: string[]) => MaybePromise<boolean | void>;
80
+ registry?: Registry.Registry;
81
+ source?: string;
82
+ relation: Node.RelationInput | Node.RelationInput[];
83
+ };
84
+
85
+ /**
86
+ * Identifier denoting a GraphBuilder.
87
+ */
88
+ export const GraphBuilderTypeId: unique symbol = Symbol.for('@dxos/app-graph/GraphBuilder');
89
+ export type GraphBuilderTypeId = typeof GraphBuilderTypeId;
90
+
91
+ /**
92
+ * GraphBuilder interface.
93
+ */
94
+ export interface GraphBuilder extends Pipeable.Pipeable {
95
+ readonly [GraphBuilderTypeId]: GraphBuilderTypeId;
96
+ readonly graph: Graph.ExpandableGraph;
97
+ readonly extensions: Atom.Atom<Record<string, BuilderExtension>>;
98
+ }
99
+
100
+ /**
101
+ * The builder provides an extensible way to compose the construction of the graph.
102
+ * @internal
103
+ */
104
+ // TODO(wittjosiah): Add api for setting subscription set and/or radius.
105
+ // Should unsubscribe from nodes that are not in the set/radius.
106
+ // Should track LRU nodes that are not in the set/radius and remove them beyond a certain threshold.
107
+ class GraphBuilderImpl implements GraphBuilder {
108
+ readonly [GraphBuilderTypeId]: GraphBuilderTypeId = GraphBuilderTypeId;
109
+
110
+ pipe() {
111
+ // eslint-disable-next-line prefer-rest-params
112
+ return Pipeable.pipeArguments(this, arguments);
113
+ }
114
+
115
+ // TODO(wittjosiah): Use Context.
116
+ /** Active subscriptions keyed by composite ID, cleaned up on node removal. */
117
+ readonly _subscriptions = new Map<string, CleanupFn>();
118
+ /** Connector updates pending flush, keyed by connector key. */
119
+ readonly _dirtyConnectors = new Map<
120
+ string,
121
+ {
122
+ nodes: Node.NodeArg<any>[];
123
+ previous: string[];
124
+ }
125
+ >();
126
+ /** Last-flushed node IDs per connector key, used for edge removal on update. */
127
+ readonly _connectorPrevious = new Map<string, string[]>();
128
+ /** Last-flushed node args per connector key, used for change detection. */
129
+ readonly _connectorPreviousArgs = new Map<string, Node.NodeArg<any>[]>();
130
+ /** Whether a dirty-flush task is already scheduled. */
131
+ _flushScheduled = false;
132
+ /** Resolves when the current flush completes. */
133
+ _flushPromise: Promise<void> = Promise.resolve();
134
+ /** Registered builder extensions keyed by extension ID. */
135
+ readonly _extensions = Atom.make(Record.empty<string, BuilderExtension>()).pipe(
136
+ Atom.keepAlive,
137
+ Atom.withLabel('graph-builder:extensions'),
138
+ );
139
+ /** Triggers signalling that a node's resolver has fired at least once. */
140
+ readonly _initialized: Record<string, Trigger> = {};
141
+ /** Shared atom registry for reactive subscriptions. */
142
+ readonly _registry: Registry.Registry;
143
+ /** Backing graph with internal accessors for node atoms and construction. */
144
+ readonly _graph: Graph.Graph & {
145
+ _node: (id: string) => Atom.Writable<Option.Option<Node.Node>>;
146
+ _constructNode: (node: Node.NodeArg<any>) => Option.Option<Node.Node>;
147
+ };
148
+
149
+ constructor({ registry, ...params }: Pick<Graph.GraphProps, 'registry' | 'nodes' | 'edges'> = {}) {
150
+ this._registry = registry ?? Registry.make();
151
+ const graph = Graph.make({
152
+ ...params,
153
+ registry: this._registry,
154
+ onExpand: (id, relation) => this._onExpand(id, relation),
155
+ onInitialize: (id) => this._onInitialize(id),
156
+ onRemoveNode: (id) => this._onRemoveNode(id),
157
+ });
158
+ // Access internal methods via type assertion since GraphBuilder needs them
159
+ this._graph = graph as Graph.Graph & {
160
+ _node: (id: string) => Atom.Writable<Option.Option<Node.Node>>;
161
+ _constructNode: (node: Node.NodeArg<any>) => Option.Option<Node.Node>;
162
+ };
163
+ }
164
+
165
+ get graph(): Graph.ExpandableGraph {
166
+ return this._graph;
167
+ }
168
+
169
+ get extensions() {
170
+ return this._extensions;
171
+ }
172
+
173
+ /** Apply a set of node changes for a single connector key. */
174
+ private _applyConnectorUpdate(key: string, nodes: Node.NodeArg<any>[], previous: string[]): void {
175
+ const { id, relation } = relationFromConnectorKey(key);
176
+ const ids = nodes.map((node) => node.id);
177
+ const removed = previous.filter((pid) => !ids.includes(pid));
178
+ this._connectorPrevious.set(key, ids);
179
+ this._connectorPreviousArgs.set(key, nodes);
180
+
181
+ Graph.removeEdges(
182
+ this._graph,
183
+ removed.map((target) => ({ source: id, target, relation })),
184
+ true,
185
+ );
186
+ Graph.addNodes(this._graph, nodes);
187
+ Graph.addEdges(
188
+ this._graph,
189
+ nodes.map((node) => ({ source: id, target: node.id, relation })),
190
+ );
191
+ if (ids.length > 0) {
192
+ const sortedIds = [...nodes]
193
+ .sort((a, b) =>
194
+ byPosition(a.properties ?? ({} as { position?: Position }), b.properties ?? ({} as { position?: Position })),
195
+ )
196
+ .map((n) => n.id);
197
+ Graph.sortEdges(this._graph, id, relation, sortedIds);
198
+ }
199
+ }
200
+
201
+ private _scheduleDirtyFlush(): void {
202
+ if (!this._flushScheduled) {
203
+ this._flushScheduled = true;
204
+ this._flushPromise = scheduleTask(
205
+ () => {
206
+ this._flushScheduled = false;
207
+ while (this._dirtyConnectors.size > 0) {
208
+ const entries = [...this._dirtyConnectors.entries()];
209
+ this._dirtyConnectors.clear();
210
+
211
+ Atom.batch(() => {
212
+ for (const [key, { nodes, previous }] of entries) {
213
+ this._applyConnectorUpdate(key, nodes, previous);
214
+ }
215
+ });
216
+ }
217
+ },
218
+ { strategy: 'smooth' },
219
+ );
220
+ }
221
+ }
222
+
223
+ private readonly _resolvers = Atom.family<string, Atom.Atom<Option.Option<Node.NodeArg<any>>>>((id) => {
224
+ return Atom.make((get) => {
225
+ return Function.pipe(
226
+ get(this._extensions),
227
+ Record.values,
228
+ Array.sortBy(byPosition),
229
+ Array.map(({ resolver }) => resolver),
230
+ Array.filter(isNonNullable),
231
+ Array.map((resolver) => get(resolver(id))),
232
+ Array.filter(isNonNullable),
233
+ Array.head,
234
+ );
235
+ });
236
+ });
237
+
238
+ private readonly _connectors = Atom.family<string, Atom.Atom<Node.NodeArg<any>[]>>((key) => {
239
+ return Atom.make((get) => {
240
+ const { id, relation } = relationFromConnectorKey(key);
241
+ const node = this._graph.node(id);
242
+
243
+ const sourceNode = Option.getOrElse(get(node), () => undefined);
244
+ if (!sourceNode) {
245
+ return [];
246
+ }
247
+
248
+ const extensions = Function.pipe(
249
+ get(this._extensions),
250
+ Record.values,
251
+ Array.sortBy(byPosition),
252
+ Array.filter(
253
+ (ext): ext is BuilderExtension & { connector: NonNullable<BuilderExtension['connector']> } =>
254
+ Graph.relationKey(ext.relation ?? 'child') === Graph.relationKey(relation) && ext.connector != null,
255
+ ),
256
+ );
257
+
258
+ const nodes: Node.NodeArg<any>[] = [];
259
+ for (const ext of extensions) {
260
+ const result = get(ext.connector(node));
261
+ nodes.push(...result);
262
+ }
263
+
264
+ return nodes;
265
+ }).pipe(Atom.withLabel(`graph-builder:connectors:${key}`));
266
+ });
267
+
268
+ private _onExpand(id: string, relation: Node.Relation): void {
269
+ log('onExpand', { id, relation, registry: getDebugName(this._registry) });
270
+ this._expandRelation(id, relation);
271
+
272
+ // TODO(wittjosiah): Remove. This is for backwards compatibility.
273
+ if (relation.kind === 'child' && relation.direction === 'outbound') {
274
+ Graph.expand(this._graph, id, 'action');
275
+ }
276
+ }
277
+
278
+ private _expandRelation(id: string, relation: Node.RelationInput): void {
279
+ const key = connectorKey(id, relation);
280
+ const connectors = this._connectors(key);
281
+
282
+ const cancel = this._registry.subscribe(
283
+ connectors,
284
+ (rawNodes) => {
285
+ const nodes = qualifyNodeArgs(id)(rawNodes);
286
+ const previous = this._connectorPrevious.get(key) ?? [];
287
+ const ids = nodes.map((n) => n.id);
288
+
289
+ if (ids.length === previous.length && ids.every((nodeId, idx) => nodeId === previous[idx])) {
290
+ const prevArgs = this._connectorPreviousArgs.get(key);
291
+ if (prevArgs && nodeArgsUnchanged(prevArgs, nodes)) {
292
+ return;
293
+ }
294
+ }
295
+
296
+ log('update', { id, relation, ids });
297
+ this._dirtyConnectors.set(key, { nodes, previous });
298
+ this._scheduleDirtyFlush();
299
+ },
300
+ { immediate: true },
301
+ );
302
+
303
+ this._subscriptions.set(subscriptionKey(id, 'expand', key), cancel);
304
+ }
305
+
306
+ private async _onInitialize(id: string) {
307
+ log('onInitialize', { id });
308
+ const resolver = this._resolvers(id);
309
+
310
+ const cancel = this._registry.subscribe(
311
+ resolver,
312
+ (node) => {
313
+ const trigger = this._initialized[id];
314
+ const connectorOwned = [...this._connectorPrevious.values()].some((ids) => ids.includes(id));
315
+ Option.match(node, {
316
+ onSome: (node) => {
317
+ if (!connectorOwned) {
318
+ Graph.addNodes(this._graph, [node]);
319
+ // Connect resolved node to its parent via a child edge.
320
+ const parentId = getParentId(id);
321
+ if (parentId) {
322
+ Graph.addEdges(this._graph, [{ source: parentId, target: id, relation: 'child' }]);
323
+ }
324
+ }
325
+ trigger?.wake();
326
+ },
327
+ onNone: () => {
328
+ trigger?.wake();
329
+ if (!connectorOwned) {
330
+ Graph.removeNodes(this._graph, [id]);
331
+ }
332
+ },
333
+ });
334
+ },
335
+ { immediate: true },
336
+ );
337
+
338
+ this._subscriptions.set(subscriptionKey(id, 'init'), cancel);
339
+ }
340
+
341
+ private _onRemoveNode(id: string): void {
342
+ for (const [key, cleanup] of this._subscriptions) {
343
+ if (primaryParts(key)[0] === id) {
344
+ cleanup();
345
+ this._subscriptions.delete(key);
346
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Creates a new GraphBuilder instance.
353
+ */
354
+ export const make = (params?: Pick<Graph.GraphProps, 'registry' | 'nodes' | 'edges'>): GraphBuilder => {
355
+ return new GraphBuilderImpl(params);
356
+ };
357
+
358
+ /**
359
+ * Creates a GraphBuilder from a serialized pickle string.
360
+ */
361
+ export const from = (pickle?: string, registry?: Registry.Registry): GraphBuilder => {
362
+ if (!pickle) {
363
+ return make({ registry });
364
+ }
365
+
366
+ const { nodes, edges } = JSON.parse(pickle);
367
+ return make({ nodes, edges, registry });
368
+ };
369
+
370
+ /**
371
+ * Implementation helper for addExtension.
372
+ */
373
+ const addExtensionImpl = (builder: GraphBuilder, extensions: BuilderExtensions): GraphBuilder => {
374
+ const internal = builder as GraphBuilderImpl;
375
+ flattenExtensions(extensions).forEach((extension) => {
376
+ const extensions = internal._registry.get(internal._extensions);
377
+ internal._registry.set(internal._extensions, Record.set(extensions, extension.id, extension));
378
+ });
379
+ return builder;
380
+ };
381
+
382
+ /**
383
+ * Add extensions to the graph builder.
384
+ */
385
+ export function addExtension(builder: GraphBuilder, extensions: BuilderExtensions): GraphBuilder;
386
+ export function addExtension(extensions: BuilderExtensions): (builder: GraphBuilder) => GraphBuilder;
387
+ export function addExtension(
388
+ builderOrExtensions: GraphBuilder | BuilderExtensions,
389
+ extensions?: BuilderExtensions,
390
+ ): GraphBuilder | ((builder: GraphBuilder) => GraphBuilder) {
391
+ if (extensions === undefined) {
392
+ // Curried: addExtension(extensions)
393
+ const extensions = builderOrExtensions as BuilderExtensions;
394
+ return (builder: GraphBuilder) => addExtensionImpl(builder, extensions);
395
+ } else {
396
+ // Direct: addExtension(builder, extensions)
397
+ const builder = builderOrExtensions as GraphBuilder;
398
+ return addExtensionImpl(builder, extensions);
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Implementation helper for removeExtension.
404
+ */
405
+ const removeExtensionImpl = (builder: GraphBuilder, id: string): GraphBuilder => {
406
+ const internal = builder as GraphBuilderImpl;
407
+ const extensions = internal._registry.get(internal._extensions);
408
+ internal._registry.set(internal._extensions, Record.remove(extensions, id));
409
+ return builder;
410
+ };
411
+
412
+ /**
413
+ * Remove an extension from the graph builder.
414
+ */
415
+ export function removeExtension(builder: GraphBuilder, id: string): GraphBuilder;
416
+ export function removeExtension(id: string): (builder: GraphBuilder) => GraphBuilder;
417
+ export function removeExtension(
418
+ builderOrId: GraphBuilder | string,
419
+ id?: string,
420
+ ): GraphBuilder | ((builder: GraphBuilder) => GraphBuilder) {
421
+ if (typeof builderOrId === 'string') {
422
+ // Curried: removeExtension(id)
423
+ const id = builderOrId;
424
+ return (builder: GraphBuilder) => removeExtensionImpl(builder, id);
425
+ } else {
426
+ // Direct: removeExtension(builder, id)
427
+ const builder = builderOrId;
428
+ return removeExtensionImpl(builder, id!);
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Implementation helper for explore.
434
+ */
435
+ const exploreImpl = async (
436
+ builder: GraphBuilder,
437
+ options: GraphBuilderTraverseOptions,
438
+ path: string[] = [],
439
+ ): Promise<void> => {
440
+ const internal = builder as GraphBuilderImpl;
441
+ const { registry = Registry.make(), source = Node.RootId, relation, visitor } = options;
442
+ // Break cycles.
443
+ if (path.includes(source)) {
444
+ return;
445
+ }
446
+
447
+ await yieldOrContinue('idle');
448
+
449
+ const node = registry.get(internal._graph.nodeOrThrow(source));
450
+ const shouldContinue = await visitor(node, [...path, node.id]);
451
+ if (shouldContinue === false) {
452
+ return;
453
+ }
454
+
455
+ const nodes = Function.pipe(
456
+ internal._registry.get(internal._extensions),
457
+ Record.values,
458
+ Array.map((extension) => extension.connector),
459
+ Array.filter(isNonNullable),
460
+ Array.flatMap((connector) => registry.get(connector(internal._graph.node(source)))),
461
+ qualifyNodeArgs(source),
462
+ );
463
+
464
+ await Promise.all(
465
+ nodes.map((nodeArg) => {
466
+ registry.set(internal._graph._node(nodeArg.id), internal._graph._constructNode(nodeArg));
467
+ return exploreImpl(builder, { registry, source: nodeArg.id, relation, visitor }, [...path, node.id]);
468
+ }),
469
+ );
470
+
471
+ if (registry !== internal._registry) {
472
+ registry.reset();
473
+ registry.dispose();
474
+ }
475
+ };
476
+
477
+ /**
478
+ * Explore the graph by traversing it with the given options.
479
+ */
480
+ export function explore(builder: GraphBuilder, options: GraphBuilderTraverseOptions, path?: string[]): Promise<void>;
481
+ export function explore(
482
+ options: GraphBuilderTraverseOptions,
483
+ path?: string[],
484
+ ): (builder: GraphBuilder) => Promise<void>;
485
+ export function explore(
486
+ builderOrOptions: GraphBuilder | GraphBuilderTraverseOptions,
487
+ optionsOrPath?: GraphBuilderTraverseOptions | string[],
488
+ path?: string[],
489
+ ): Promise<void> | ((builder: GraphBuilder) => Promise<void>) {
490
+ if (typeof builderOrOptions === 'object' && 'visitor' in builderOrOptions) {
491
+ // Curried: explore(options, path?)
492
+ const options = builderOrOptions as GraphBuilderTraverseOptions;
493
+ const path = Array.isArray(optionsOrPath) ? optionsOrPath : undefined;
494
+ return (builder: GraphBuilder) => exploreImpl(builder, options, path);
495
+ } else {
496
+ // Direct: explore(builder, options, path?)
497
+ const builder = builderOrOptions as GraphBuilder;
498
+ const options = optionsOrPath as GraphBuilderTraverseOptions;
499
+ const pathArg = path ?? (Array.isArray(optionsOrPath) ? optionsOrPath : undefined);
500
+ return exploreImpl(builder, options, pathArg);
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Implementation helper for destroy.
506
+ */
507
+ const destroyImpl = (builder: GraphBuilder): void => {
508
+ const internal = builder as GraphBuilderImpl;
509
+ internal._subscriptions.forEach((unsubscribe) => unsubscribe());
510
+ internal._subscriptions.clear();
511
+ };
512
+
513
+ /**
514
+ * Destroy the graph builder and clean up resources.
515
+ */
516
+ export function destroy(builder: GraphBuilder): void;
517
+ export function destroy(): (builder: GraphBuilder) => void;
518
+ export function destroy(builder?: GraphBuilder): void | ((builder: GraphBuilder) => void) {
519
+ if (builder === undefined) {
520
+ // Curried: destroy()
521
+ return (builder: GraphBuilder) => destroyImpl(builder);
522
+ } else {
523
+ // Direct: destroy(builder)
524
+ return destroyImpl(builder);
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Wait for all pending connector updates to be flushed.
530
+ */
531
+ export const flush = (builder: GraphBuilder): Promise<void> => {
532
+ return (builder as GraphBuilderImpl)._flushPromise;
533
+ };
534
+
535
+ //
536
+ // Extension Creation
537
+ //
44
538
 
45
539
  /**
46
540
  * A graph builder extension is used to add nodes to the graph.
@@ -53,9 +547,9 @@ export type ActionGroupsExtension = (
53
547
  * @param params.actions A function to add actions to the graph based on a connection to an existing node.
54
548
  * @param params.actionGroups A function to add action groups to the graph based on a connection to an existing node.
55
549
  */
56
- export type CreateExtensionOptions = {
550
+ export type CreateExtensionRawOptions = {
57
551
  id: string;
58
- relation?: Relation;
552
+ relation?: Node.RelationInput;
59
553
  position?: Position;
60
554
  resolver?: ResolverExtension;
61
555
  connector?: ConnectorExtension;
@@ -64,18 +558,19 @@ export type CreateExtensionOptions = {
64
558
  };
65
559
 
66
560
  /**
67
- * Create a graph builder extension.
561
+ * Create a graph builder extension (low-level API that works directly with Atoms).
68
562
  */
69
- export const createExtension = (extension: CreateExtensionOptions): BuilderExtension[] => {
563
+ export const createExtensionRaw = (extension: CreateExtensionRawOptions): BuilderExtension[] => {
70
564
  const {
71
565
  id,
72
566
  position = 'static',
73
- relation = 'outbound',
567
+ relation = 'child',
74
568
  resolver: _resolver,
75
569
  connector: _connector,
76
570
  actions: _actions,
77
571
  actionGroups: _actionGroups,
78
572
  } = extension;
573
+ const normalizedRelation = normalizeRelation(relation);
79
574
  const getId = (key: string) => `${id}/${key}`;
80
575
 
81
576
  const resolver =
@@ -83,19 +578,19 @@ export const createExtension = (extension: CreateExtensionOptions): BuilderExten
83
578
 
84
579
  const connector =
85
580
  _connector &&
86
- Atom.family((node: Atom.Atom<Option.Option<Node>>) =>
581
+ Atom.family((node: Atom.Atom<Option.Option<Node.Node>>) =>
87
582
  _connector(node).pipe(Atom.withLabel(`graph-builder:_connector:${id}`)),
88
583
  );
89
584
 
90
585
  const actionGroups =
91
586
  _actionGroups &&
92
- Atom.family((node: Atom.Atom<Option.Option<Node>>) =>
587
+ Atom.family((node: Atom.Atom<Option.Option<Node.Node>>) =>
93
588
  _actionGroups(node).pipe(Atom.withLabel(`graph-builder:_actionGroups:${id}`)),
94
589
  );
95
590
 
96
591
  const actions =
97
592
  _actions &&
98
- Atom.family((node: Atom.Atom<Option.Option<Node>>) =>
593
+ Atom.family((node: Atom.Atom<Option.Option<Node.Node>>) =>
99
594
  _actions(node).pipe(Atom.withLabel(`graph-builder:_actions:${id}`)),
100
595
  );
101
596
 
@@ -105,13 +600,13 @@ export const createExtension = (extension: CreateExtensionOptions): BuilderExten
105
600
  ? ({
106
601
  id: getId('connector'),
107
602
  position,
108
- relation,
603
+ relation: normalizedRelation,
109
604
  connector: Atom.family((node) =>
110
605
  Atom.make((get) => {
111
606
  try {
112
607
  return get(connector(node));
113
- } catch {
114
- log.warn('Error in connector', { id: getId('connector'), node });
608
+ } catch (error) {
609
+ log.warn('Error in connector', { id: getId('connector'), node, error });
115
610
  return [];
116
611
  }
117
612
  }).pipe(Atom.withLabel(`graph-builder:connector:${id}`)),
@@ -122,17 +617,17 @@ export const createExtension = (extension: CreateExtensionOptions): BuilderExten
122
617
  ? ({
123
618
  id: getId('actionGroups'),
124
619
  position,
125
- relation: 'outbound',
620
+ relation: Node.actionRelation(),
126
621
  connector: Atom.family((node) =>
127
622
  Atom.make((get) => {
128
623
  try {
129
624
  return get(actionGroups(node)).map((arg) => ({
130
625
  ...arg,
131
- data: actionGroupSymbol,
132
- type: ACTION_GROUP_TYPE,
626
+ data: Node.actionGroupSymbol,
627
+ type: Node.ActionGroupType,
133
628
  }));
134
- } catch {
135
- log.warn('Error in actionGroups', { id: getId('actionGroups'), node });
629
+ } catch (error) {
630
+ log.warn('Error in actionGroups', { id: getId('actionGroups'), node, error });
136
631
  return [];
137
632
  }
138
633
  }).pipe(Atom.withLabel(`graph-builder:connector:actionGroups:${id}`)),
@@ -143,13 +638,13 @@ export const createExtension = (extension: CreateExtensionOptions): BuilderExten
143
638
  ? ({
144
639
  id: getId('actions'),
145
640
  position,
146
- relation: 'outbound',
641
+ relation: Node.actionRelation(),
147
642
  connector: Atom.family((node) =>
148
643
  Atom.make((get) => {
149
644
  try {
150
- return get(actions(node)).map((arg) => ({ ...arg, type: ACTION_TYPE }));
151
- } catch {
152
- log.warn('Error in actions', { id: getId('actions'), node });
645
+ return get(actions(node)).map((arg) => ({ ...arg, type: Node.ActionType }));
646
+ } catch (error) {
647
+ log.warn('Error in actions', { id: getId('actions'), node, error });
153
648
  return [];
154
649
  }
155
650
  }).pipe(Atom.withLabel(`graph-builder:connector:actions:${id}`)),
@@ -159,281 +654,207 @@ export const createExtension = (extension: CreateExtensionOptions): BuilderExten
159
654
  ].filter(isNonNullable);
160
655
  };
161
656
 
162
- export type GraphBuilderTraverseOptions = {
163
- visitor: (node: Node, path: string[]) => MaybePromise<boolean | void>;
164
- registry?: Registry.Registry;
165
- source?: string;
166
- relation?: Relation;
167
- };
168
-
169
- export type BuilderExtension = Readonly<{
657
+ /**
658
+ * Options for creating a graph builder extension with simplified API.
659
+ * All callbacks must return Effects for dependency injection.
660
+ * Effects may fail - errors are caught, logged, and the extension returns empty results.
661
+ */
662
+ export type CreateExtensionOptions<TMatched = Node.Node, R = never> = {
170
663
  id: string;
171
- position: Position;
172
- relation?: Relation; // Only for connector.
173
- resolver?: ResolverExtension;
174
- connector?: (node: Atom.Atom<Option.Option<Node>>) => Atom.Atom<NodeArg<any>[]>;
175
- }>;
176
-
177
- export type BuilderExtensions = BuilderExtension | BuilderExtension[] | BuilderExtensions[];
178
-
179
- export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
180
- if (Array.isArray(extension)) {
181
- return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];
182
- } else {
183
- return [...acc, extension];
184
- }
664
+ match: (node: Node.Node) => Option.Option<TMatched>;
665
+ actions?: (
666
+ matched: TMatched,
667
+ get: Atom.Context,
668
+ ) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>, any>, 'type'>[], Error, R>;
669
+ connector?: (matched: TMatched, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any>[], Error, R>;
670
+ resolver?: (id: string, get: Atom.Context) => Effect.Effect<Node.NodeArg<any, any> | null, Error, R>;
671
+ relation?: Node.RelationInput;
672
+ position?: Position;
185
673
  };
186
674
 
187
675
  /**
188
- * The builder provides an extensible way to compose the construction of the graph.
676
+ * Run an Effect synchronously with the provided context.
677
+ * If the effect fails, logs the error and returns the fallback value.
678
+ * @internal
189
679
  */
190
- // TODO(wittjosiah): Add api for setting subscription set and/or radius.
191
- // Should unsubscribe from nodes that are not in the set/radius.
192
- // Should track LRU nodes that are not in the set/radius and remove them beyond a certain threshold.
193
- export class GraphBuilder {
194
- // TODO(wittjosiah): Use Context.
195
- private readonly _subscriptions = new Map<string, CleanupFn>();
196
- private readonly _extensions = Atom.make(Record.empty<string, BuilderExtension>()).pipe(
197
- Atom.keepAlive,
198
- Atom.withLabel('graph-builder:extensions'),
199
- );
200
- private readonly _initialized: Record<string, Trigger> = {};
201
- private readonly _registry: Registry.Registry;
202
- private readonly _graph: Graph;
203
-
204
- constructor({ registry, ...params }: Pick<GraphParams, 'registry' | 'nodes' | 'edges'> = {}) {
205
- this._registry = registry ?? Registry.make();
206
- this._graph = new Graph({
207
- ...params,
208
- registry: this._registry,
209
- onExpand: (id, relation) => this._onExpand(id, relation),
210
- onInitialize: (id) => this._onInitialize(id),
211
- onRemoveNode: (id) => this._onRemoveNode(id),
212
- });
213
- }
214
-
215
- static from(pickle?: string, registry?: Registry.Registry): GraphBuilder {
216
- if (!pickle) {
217
- return new GraphBuilder({ registry });
218
- }
219
-
220
- const { nodes, edges } = JSON.parse(pickle);
221
- return new GraphBuilder({ nodes, edges, registry });
222
- }
223
-
224
- get graph(): ExpandableGraph {
225
- return this._graph;
226
- }
227
-
228
- get extensions() {
229
- return this._extensions;
230
- }
231
-
232
- addExtension(extensions: BuilderExtensions): GraphBuilder {
233
- flattenExtensions(extensions).forEach((extension) => {
234
- const extensions = this._registry.get(this._extensions);
235
- this._registry.set(this._extensions, Record.set(extensions, extension.id, extension));
236
- });
237
- return this;
238
- }
239
-
240
- removeExtension(id: string): GraphBuilder {
241
- const extensions = this._registry.get(this._extensions);
242
- this._registry.set(this._extensions, Record.remove(extensions, id));
243
- return this;
244
- }
245
-
246
- async explore(
247
- // TODO(wittjosiah): Currently defaulting to new registry.
248
- // Currently unsure about how to handle nodes which are expanded in the background.
249
- // This seems like a good place to start.
250
- { registry = Registry.make(), source = ROOT_ID, relation = 'outbound', visitor }: GraphBuilderTraverseOptions,
251
- path: string[] = [],
252
- ): Promise<void> {
253
- // Break cycles.
254
- if (path.includes(source)) {
255
- return;
256
- }
257
-
258
- // TODO(wittjosiah): This is a workaround for esm not working in the test runner.
259
- // Switching to vitest is blocked by having node esm versions of echo-schema & echo-signals.
260
- if (!isNode()) {
261
- const { yieldOrContinue } = await import('main-thread-scheduling');
262
- await yieldOrContinue('idle');
263
- }
264
-
265
- const node = registry.get(this._graph.nodeOrThrow(source));
266
- const shouldContinue = await visitor(node, [...path, node.id]);
267
- if (shouldContinue === false) {
268
- return;
269
- }
270
-
271
- const nodes = Object.values(this._registry.get(this._extensions))
272
- .filter((extension) => relation === (extension.relation ?? 'outbound'))
273
- .map((extension) => extension.connector)
274
- .filter(isNonNullable)
275
- .flatMap((connector) => registry.get(connector(this._graph.node(source))));
276
-
277
- await Promise.all(
278
- nodes.map((nodeArg) => {
279
- registry.set(this._graph._node(nodeArg.id), this._graph._constructNode(nodeArg));
280
- return this.explore({ registry, source: nodeArg.id, relation, visitor }, [...path, node.id]);
680
+ const runEffectSyncWithFallback = <T, R>(
681
+ effect: Effect.Effect<T, Error, R>,
682
+ context: Context.Context<R>,
683
+ extensionId: string,
684
+ fallback: T,
685
+ ): T => {
686
+ return Effect.runSync(
687
+ effect.pipe(
688
+ Effect.provide(context),
689
+ Effect.catchAll((error) => {
690
+ log.warn('Extension failed', { extension: extensionId, error });
691
+ return Effect.succeed(fallback);
281
692
  }),
282
- );
283
-
284
- if (registry !== this._registry) {
285
- registry.reset();
286
- registry.dispose();
287
- }
288
- }
289
-
290
- destroy(): void {
291
- this._subscriptions.forEach((unsubscribe) => unsubscribe());
292
- this._subscriptions.clear();
293
- }
693
+ ),
694
+ );
695
+ };
294
696
 
295
- private readonly _resolvers = Atom.family<string, Atom.Atom<Option.Option<NodeArg<any>>>>((id) => {
296
- return Atom.make((get) => {
297
- return Function.pipe(
298
- get(this._extensions),
299
- Record.values,
300
- Array.sortBy(byPosition),
301
- Array.map(({ resolver }) => resolver),
302
- Array.filter(isNonNullable),
303
- Array.map((resolver) => get(resolver(id))),
304
- Array.filter(isNonNullable),
305
- Array.head,
306
- );
697
+ /**
698
+ * Create a graph builder extension with simplified API.
699
+ * Returns an Effect to allow callbacks to access services via dependency injection.
700
+ */
701
+ export const createExtension = <TMatched = Node.Node, R = never>(
702
+ options: CreateExtensionOptions<TMatched, R>,
703
+ ): Effect.Effect<BuilderExtension[], never, R> =>
704
+ Effect.map(Effect.context<R>(), (context) => {
705
+ const { id, match, actions, connector, resolver, relation, position } = options;
706
+
707
+ const connectorExtension = connector ? createConnectorWithRuntime(id, match, connector, context) : undefined;
708
+
709
+ const actionsExtension = actions
710
+ ? (node: Atom.Atom<Option.Option<Node.Node>>) =>
711
+ Atom.make((get) =>
712
+ Function.pipe(
713
+ get(node),
714
+ Option.flatMap(match),
715
+ Option.map((matched) =>
716
+ runEffectSyncWithFallback(actions(matched, get), context, id, []).map((action) => ({
717
+ ...action,
718
+ // Attach captured context for action execution.
719
+ _actionContext: context,
720
+ })),
721
+ ),
722
+ Option.getOrElse(() => []),
723
+ ),
724
+ )
725
+ : undefined;
726
+
727
+ const resolverExtension = resolver
728
+ ? (nodeId: string) =>
729
+ Atom.make((get) => runEffectSyncWithFallback(resolver(nodeId, get), context, id, null) ?? null)
730
+ : undefined;
731
+
732
+ return createExtensionRaw({
733
+ id,
734
+ relation,
735
+ position,
736
+ connector: connectorExtension,
737
+ actions: actionsExtension,
738
+ resolver: resolverExtension,
307
739
  });
308
740
  });
309
741
 
310
- private readonly _connectors = Atom.family<string, Atom.Atom<NodeArg<any>[]>>((key) => {
311
- return Atom.make((get) => {
312
- const [id, relation] = key.split('+');
313
- const node = this._graph.node(id);
314
-
315
- return Function.pipe(
316
- get(this._extensions),
317
- Record.values,
318
- // TODO(wittjosiah): Sort on write rather than read.
319
- Array.sortBy(byPosition),
320
- Array.filter(({ relation: _relation = 'outbound' }) => _relation === relation),
321
- Array.map(({ connector }) => connector?.(node)),
322
- Array.filter(isNonNullable),
323
- Array.flatMap((result) => get(result)),
324
- );
325
- }).pipe(Atom.withLabel(`graph-builder:connectors:${key}`));
326
- });
327
-
328
- private _onExpand(id: string, relation: Relation): void {
329
- log('onExpand', { id, relation, registry: getDebugName(this._registry) });
330
- const connectors = this._connectors(`${id}+${relation}`);
331
-
332
- let previous: string[] = [];
333
- const cancel = this._registry.subscribe(
334
- connectors,
335
- (nodes) => {
336
- const ids = nodes.map((n) => n.id);
337
- const removed = previous.filter((id) => !ids.includes(id));
338
- previous = ids;
339
-
340
- log('update', { id, relation, ids, removed });
341
- const update = () => {
342
- Atom.batch(() => {
343
- this._graph.removeEdges(
344
- removed.map((target) => ({ source: id, target })),
345
- true,
346
- );
347
- this._graph.addNodes(nodes);
348
- this._graph.addEdges(
349
- nodes.map((node) =>
350
- relation === 'outbound' ? { source: id, target: node.id } : { source: node.id, target: id },
351
- ),
352
- );
353
- this._graph.sortEdges(
354
- id,
355
- relation,
356
- nodes.map(({ id }) => id),
357
- );
358
- });
359
- };
360
-
361
- // TODO(wittjosiah): Remove `requestAnimationFrame` once we have a better solution.
362
- // This is a workaround to avoid a race condition where the graph is updated during React render.
363
- if (typeof requestAnimationFrame === 'function') {
364
- requestAnimationFrame(update);
365
- } else {
366
- update();
367
- }
368
- },
369
- { immediate: true },
742
+ /**
743
+ * Create a connector extension from a matcher and factory function.
744
+ * The factory's data type is inferred from the matcher's return type.
745
+ */
746
+ export const createConnector = <TData>(
747
+ matcher: (node: Node.Node) => Option.Option<TData>,
748
+ factory: (data: TData, get: Atom.Context) => Node.NodeArg<any>[],
749
+ ): ConnectorExtension => {
750
+ return (node: Atom.Atom<Option.Option<Node.Node>>) =>
751
+ Atom.make((get) =>
752
+ Function.pipe(
753
+ get(node),
754
+ Option.flatMap(matcher),
755
+ Option.map((data) => factory(data, get)),
756
+ Option.getOrElse(() => []),
757
+ ),
370
758
  );
759
+ };
371
760
 
372
- this._subscriptions.set(id, cancel);
373
- }
374
-
375
- // TODO(wittjosiah): If the same node is added by a connector, the resolver should probably cancel itself?
376
- private async _onInitialize(id: string) {
377
- log('onInitialize', { id });
378
- const resolver = this._resolvers(id);
379
-
380
- const cancel = this._registry.subscribe(
381
- resolver,
382
- (node) => {
383
- const trigger = this._initialized[id];
384
- Option.match(node, {
385
- onSome: (node) => {
386
- this._graph.addNodes([node]);
387
- trigger?.wake();
388
- },
389
- onNone: () => {
390
- trigger?.wake();
391
- this._graph.removeNodes([id]);
392
- },
393
- });
394
- },
395
- { immediate: true },
761
+ /**
762
+ * Create a connector extension from a matcher and factory function with Effect support.
763
+ * The factory must return an Effect. Errors are caught and logged.
764
+ * @internal
765
+ */
766
+ const createConnectorWithRuntime = <TData, R>(
767
+ extensionId: string,
768
+ matcher: (node: Node.Node) => Option.Option<TData>,
769
+ factory: (data: TData, get: Atom.Context) => Effect.Effect<Node.NodeArg<any>[], Error, R>,
770
+ context: Context.Context<R>,
771
+ ): ConnectorExtension => {
772
+ return (node: Atom.Atom<Option.Option<Node.Node>>) =>
773
+ Atom.make((get) =>
774
+ Function.pipe(
775
+ get(node),
776
+ Option.flatMap(matcher),
777
+ Option.map((data) => runEffectSyncWithFallback(factory(data, get), context, extensionId, [])),
778
+ Option.getOrElse(() => []),
779
+ ),
396
780
  );
781
+ };
397
782
 
398
- this._subscriptions.set(id, cancel);
399
- }
783
+ /**
784
+ * Options for creating a type-based extension.
785
+ * All callbacks must return Effects for dependency injection.
786
+ * Effects may fail - errors are caught, logged, and the extension returns empty results.
787
+ */
788
+ export type CreateTypeExtensionOptions<T extends Type.AnyEntity = Type.AnyEntity, R = never> = {
789
+ id: string;
790
+ type: T;
791
+ actions?: (
792
+ object: Entity.Entity<Schema.Schema.Type<T>>,
793
+ get: Atom.Context,
794
+ ) => Effect.Effect<Omit<Node.NodeArg<Node.ActionData<any>>, 'type'>[], Error, R>;
795
+ connector?: (
796
+ object: Entity.Entity<Schema.Schema.Type<T>>,
797
+ get: Atom.Context,
798
+ ) => Effect.Effect<Node.NodeArg<any>[], Error, R>;
799
+ relation?: Node.RelationInput;
800
+ position?: Position;
801
+ };
400
802
 
401
- private _onRemoveNode(id: string): void {
402
- this._subscriptions.get(id)?.();
403
- this._subscriptions.delete(id);
404
- }
405
- }
803
+ /**
804
+ * Create an extension that matches nodes by schema type.
805
+ * The entity type is inferred from the schema type and works for both object and relation schemas.
806
+ * Returns an Effect to allow callbacks to access services via dependency injection.
807
+ */
808
+ export const createTypeExtension = <T extends Type.AnyEntity, R = never>(
809
+ options: CreateTypeExtensionOptions<T, R>,
810
+ ): Effect.Effect<BuilderExtension[], never, R> => {
811
+ const { id, type, actions, connector, relation, position } = options;
812
+ return createExtension<Entity.Entity<Schema.Schema.Type<T>>, R>({
813
+ id,
814
+ match: NodeMatcher.whenEchoType(type),
815
+ actions,
816
+ connector,
817
+ relation,
818
+ position,
819
+ });
820
+ };
821
+
822
+ //
823
+ // Extension Utilities
824
+ //
406
825
 
407
826
  /**
408
- * Creates an Atom.Atom<T> from a callback which accesses signals.
409
- * Will return a new atom instance each time.
827
+ * Qualify node IDs by prefixing with the parent path.
828
+ * Validates that segment IDs do not contain the path separator.
829
+ * Recursively qualifies inline child nodes.
410
830
  */
411
- export const atomFromSignal = <T>(cb: () => T): Atom.Atom<T> => {
412
- return Atom.make((get) => {
413
- const dispose = effect(() => {
414
- get.setSelf(cb());
831
+ const qualifyNodeArgs =
832
+ (parentId: string) =>
833
+ (nodes: Node.NodeArg<any>[]): Node.NodeArg<any>[] =>
834
+ nodes.map((node) => {
835
+ validateSegmentId(node.id);
836
+ const qualified = qualifyId(parentId, node.id);
837
+ return {
838
+ ...node,
839
+ id: qualified,
840
+ nodes: node.nodes ? qualifyNodeArgs(qualified)(node.nodes) : undefined,
841
+ };
415
842
  });
416
843
 
417
- get.addFinalizer(() => dispose());
844
+ const connectorKey = (id: string, relation: Node.RelationInput): string => primaryKey(id, Graph.relationKey(relation));
418
845
 
419
- return cb();
420
- });
846
+ const relationFromConnectorKey = (key: string): { id: string; relation: Node.Relation } => {
847
+ const [id, encodedRelation] = primaryParts(key);
848
+ return { id, relation: Graph.relationFromKey(encodedRelation) };
421
849
  };
422
850
 
423
- const observableFamily = Atom.family((observable: MulticastObservable<any>) => {
424
- return Atom.make((get) => {
425
- const subscription = observable.subscribe((value) => get.setSelf(value));
851
+ const subscriptionKey = (id: string, kind: string, detail?: string): string =>
852
+ detail != null ? primaryKey(id, kind, detail) : primaryKey(id, kind);
426
853
 
427
- get.addFinalizer(() => subscription.unsubscribe());
428
-
429
- return observable.get();
430
- });
431
- });
432
-
433
- /**
434
- * Creates an Atom.Atom<T> from a MulticastObservable<T>
435
- * Will return the same atom instance for the same observable.
436
- */
437
- export const atomFromObservable = <T>(observable: MulticastObservable<T>): Atom.Atom<T> => {
438
- return observableFamily(observable) as Atom.Atom<T>;
854
+ export const flattenExtensions = (extension: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => {
855
+ if (Array.isArray(extension)) {
856
+ return [...acc, ...extension.flatMap((ext) => flattenExtensions(ext, acc))];
857
+ } else {
858
+ return [...acc, extension];
859
+ }
439
860
  };