// // Copyright 2025 DXOS.org // import type * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Function from 'effect/Function'; import * as Option from 'effect/Option'; import * as Atom from 'effect/reactivity/Atom'; import * as Registry from 'effect/reactivity/AtomRegistry'; import { Entity, type Type } from '@dxos/echo'; // eslint-disable-next-line @dxos/rules/import-as-namespace -- `GraphBuilder` is this file's own class. import * as Builder from '@dxos/graph/GraphBuilder'; import * as GraphNode from '@dxos/graph/GraphNode'; import { invariant } from '@dxos/invariant'; import { DXN } from '@dxos/keys'; import { log } from '@dxos/log'; import { isNonNullable } from '@dxos/util'; import * as Position from '@dxos/util/Position'; import { type FrameBudget, makeFrameBudget, scheduleTask, yieldOrContinue } from '#scheduler'; import * as Graph from './AppGraph.ts'; import * as Node from './AppGraphNode.ts'; import { nodeArgsUnchanged, normalizeRelation, withLabel } from './util.ts'; // // Extension Types // /** * Graph builder extension for adding nodes to the graph based on a connection to an existing node. * * @param params.node The existing node the returned nodes will be connected to. */ export type ConnectorExtension = Builder.Connector>; /** * Constrained case of the connector extension for more easily adding actions to the graph. */ export type ActionsExtension = ( node: Atom.Atom>, ) => Atom.Atom>, 'type' | 'nodes' | 'edges'>[]>; /** * Constrained case of the connector extension for more easily adding action groups to the graph. */ export type ActionGroupsExtension = ( node: Atom.Atom>, ) => Atom.Atom, 'type' | 'data' | 'nodes' | 'edges'>[]>; /** * A generic builder extension specialized to the app vocabulary: app nodes, app relations, and a * {@link UrlBinding} as the extension metadata the app layer's node decorator reads. */ export type BuilderExtension = Builder.Extension, Node.RelationInput, UrlBinding>; export type BuilderExtensions = Builder.Extensions; /** * How an extension's nodes map to (and from) the URL pair chain. The `kind` is the resolution tier: * * - `'item'` — Addressed by an id under the workspace (`doc/`). May itself have children. * - `'singleton'` — A single fixed node per workspace, addressed by the key alone (`settings`); its own * segment is the key, below `path`. * * The binding's shape (`workspace`, `path`, `minDepth`) decides which node ids it addresses, and * a node's URL comes from the one binding its id fits. A node therefore has a URL whether or not it is * loaded, and bindings of different keys must not claim the same ids. * * The anchor and linked tiers are not declared per extension: they are fixed keys of the URL grammar, * configured once on the builder as {@link UrlGrammar}. */ export type UrlBinding = { key: string; kind: 'item' | 'singleton'; /** Node-id segments between the workspace and the node's own. */ path: readonly string[]; /** * The fewest segments an item's id spans after `path`, joined by the grammar's tail separator; defaults * to 1. Of two bindings sharing a path, the larger minimum claims the deeper ids. Ignored with `resolve`. */ minDepth?: number; /** Narrows the workspaces the binding applies to; defaults to every workspace. */ workspace?: (workspace: string) => boolean; /** * Forward resolution for a data-dependent shape (nested collections, say): the id is the node's last * segment, at any depth below `path`, and this locates the rest. */ resolve?: PathResolver; }; /** * The URL grammar the builder resolves and stamps against, configured once at construction. * * The two keys are fixed tiers no extension declares (no connector produces their nodes): `anchorKey` * establishes the base that following pairs resolve against and is consumed as a rebase * (`w/`); `linked` addresses a node attached to the preceding item through its relation * (`/`, its segment ``). `tailSeparator` joins the node-id segments * after a key's static `path` into one URL id (`db/+`), so a nested shape needs no resolver. */ export type UrlGrammar = { anchorKey?: string; linked: LinkedGrammar; tailSeparator: string; }; /** How a node attached to the preceding item is addressed: its URL key, its relation, its segment prefix. */ export type LinkedGrammar = { key: string; relation: Node.RelationInput; prefix: string }; /** {@link UrlGrammar} as supplied at construction; anything omitted falls back to its default. */ export type UrlGrammarProps = Partial> & { linked?: Partial }; /** Default linked grammar; the prefix mirrors `@dxos/react-ui-attention`'s `linkedSegment`. */ const DEFAULT_LINKED: LinkedGrammar = { key: 'linked', relation: 'linked', prefix: '~' }; /** Default tail separator; never appears in an entity id or a type slug. Internal, as above. */ const DEFAULT_TAIL_SEPARATOR = '+'; /** Params passed to a {@link PathResolver} for a single `(key, id)` URL pair. */ export type PathResolveParams = { /** The id segment from the `(key, id)` pair. */ id: string; /** The workspace segment from the URL. */ workspace: string; /** Qualified id of the workspace base node (`${GraphNode.RootId}/`). */ workspaceBaseId: string; }; /** * Forward resolution for a {@link UrlBinding} whose ids are data-dependent. Returns the candidate qualified * node id — `path-resolution.ts` then materializes its ancestors and verifies it — or `null` if the id * can't be located. Must be self-contained (the declaring plugin closes over any services it needs), so * `@dxos/app-graph` stays free of service dependencies. */ export type PathResolver = (params: PathResolveParams) => Effect.Effect; const startsWith = (segments: readonly string[], prefix: readonly string[]): boolean => prefix.length <= segments.length && prefix.every((segment, index) => segments[index] === segment); /** * The `(key, id?)` a binding gives the node `nodeId`, if its shape addresses that node: the reverse of * forward resolution, minus the workspace. A singleton has no id; a resolver-backed item keeps its last * segment; any other item joins the segments after `path` with the tail separator. */ export const urlRepresentation = ( nodeId: string, url: UrlBinding, tailSeparator: string = DEFAULT_TAIL_SEPARATOR, ): Option.Option<{ key: string; id?: string }> => { const [, workspace, ...segments] = nodeId.split(GraphNode.PathSeparator); if (!workspace || !(url.workspace?.(workspace) ?? true) || !startsWith(segments, url.path)) { return Option.none(); } const tail = segments.slice(url.path.length); if (url.kind === 'singleton') { return tail.length === 1 && tail[0] === url.key ? Option.some({ key: url.key }) : Option.none(); } if (tail.length === 0) { return Option.none(); } if (url.resolve) { return Option.some({ key: url.key, id: tail[tail.length - 1] }); } return tail.length >= (url.minDepth ?? 1) ? Option.some({ key: url.key, id: tail.join(tailSeparator) }) : Option.none(); }; /** * The node a binding without a resolver addresses by `id` under `workspace`, if its shape admits that id: * the inverse of {@link urlRepresentation}. A singleton takes no id. */ export const urlCandidate = ( url: UrlBinding, workspace: string, id: string | undefined, tailSeparator: string = DEFAULT_TAIL_SEPARATOR, ): Option.Option => { if (url.resolve || !(url.workspace?.(workspace) ?? true)) { return Option.none(); } const base = [GraphNode.RootId, workspace, ...url.path]; if (url.kind === 'singleton') { return id === undefined ? Option.some([...base, url.key].join(GraphNode.PathSeparator)) : Option.none(); } const tail = id?.split(tailSeparator) ?? []; return tail.length > 0 && tail.length >= (url.minDepth ?? 1) ? Option.some([...base, ...tail].join(GraphNode.PathSeparator)) : Option.none(); }; /** * A node's own URL pair segment — `/[/]`, with no workspace/anchor prefix — or `undefined` when * the binding does not address it. A full URL is composed by prefixing `/w/`. */ export const nodeUrlSegment = ( nodeId: string, url: UrlBinding, tailSeparator: string = DEFAULT_TAIL_SEPARATOR, ): string | undefined => Option.match(urlRepresentation(nodeId, url, tailSeparator), { onNone: () => undefined, onSome: ({ key, id }) => (id === undefined ? `/${key}` : `/${key}/${id}`), }); // // Builder // export type GraphBuilderTraverseOptions = Builder.TraverseOptions; /** Construction params: the backing graph's props plus the URL grammar's fixed keys. */ export type GraphBuilderProps = Pick & { urlGrammar?: UrlGrammarProps; /** * Applied to each connector-produced node before it enters the graph. Defaults to stamping the * URL segment implied by the producing extension's binding. */ decorateNode?: (node: Node.NodeArg, extension?: BuilderExtension) => Node.NodeArg; }; /** * The generic expansion engine specialized to the app vocabulary: app nodes and relations over an * {@link Graph.ExpandableGraph}, extensions carrying a {@link UrlBinding}, and connector-produced nodes * stamped with their URL segment as they enter the graph. */ export class GraphBuilder extends Builder.GraphBuilder< Node.Node, Node.NodeArg, Node.RelationInput, UrlBinding, Graph.ExpandableGraph > { /** The URL grammar (see {@link UrlGrammar}); the keys are absent when URLs are not in play. */ readonly urlGrammar: UrlGrammar; readonly #frameBudget = makeFrameBudget(); constructor({ registry, urlGrammar, decorateNode, ...graphProps }: GraphBuilderProps = {}) { const grammar: UrlGrammar = { tailSeparator: DEFAULT_TAIL_SEPARATOR, ...urlGrammar, linked: { ...DEFAULT_LINKED, ...urlGrammar?.linked }, }; super({ registry, relationKey: (relation) => Graph.relationKey(relation ?? 'child'), inline, unchanged: nodeArgsUnchanged, decorateNode, store: (hooks, resolvedRegistry) => makeStore(graphProps, hooks, resolvedRegistry), }); // An empty prefix would make every segment read as linked. invariant(grammar.linked.prefix.length > 0, 'UrlGrammar.linked.prefix must not be empty'); invariant(grammar.tailSeparator.length > 0, 'UrlGrammar.tailSeparator must not be empty'); this.urlGrammar = grammar; } /** Hand flushes to the scheduler so a large expansion yields to the main thread. */ override _schedule(callback: () => void): Promise { return scheduleTask(callback, { strategy: 'smooth' }); } override _yield(): Promise { return yieldOrContinue('idle'); } override _frameBudget(): FrameBudget | undefined { return this.#frameBudget; } override _onReleaseRelation(target: { id: string; relation: string }): void { super._onReleaseRelation(target); Graph.releaseRelation(this.graph, target.id, target.relation); } override _onExpand(id: string, relation: string): void { super._onExpand(id, relation); // TODO(wittjosiah): Remove. This is for backwards compatibility. if (relation === CHILD_RELATION) { Graph.expandSync(this.graph, id, Node.action); } } } const CHILD_RELATION = Graph.relationKey('child'); /** How an app node argument's inline descendants are traversed: its child nodes and its actions. */ const inline: Builder.Inline> = { children: (node) => [...(node.nodes ?? []), ...(node.actions ?? [])], map: (node, fn) => ({ ...node, nodes: node.nodes?.map(fn), actions: node.actions?.map(fn) }), }; /** Adapt the app graph to the engine's store port, translating between relation keys and relations. */ const makeStore = ( props: Pick, hooks: Builder.StoreHooks, registry: Registry.AtomRegistry, ): Builder.Store, Graph.ExpandableGraph> => { const decode = ({ source, target, relation }: Builder.Edge): Graph.Edge => ({ source, target, relation: Graph.relationFromKey(relation), }); const graph = Graph.make({ ...props, registry, onExpand: (id, relation) => hooks.onExpand(id, Graph.relationKey(relation)), onRemoveNode: hooks.onRemoveNode, }); // Connectors read node atoms between writes, so the builder keeps them pinned for its lifetime. const release = Graph.retain(graph); return { graph, node: (id) => graph.node(id), addNodes: (nodes) => void Graph.addNodes(graph, [...nodes]), removeNodes: (ids, edges) => void Graph.removeNodes(graph, [...ids], edges), addEdges: (edges) => void Graph.addEdges(graph, edges.map(decode)), removeEdges: (edges, removeOrphans) => void Graph.removeEdges(graph, edges.map(decode), removeOrphans), sortEdges: (id, relation, order) => void Graph.sortEdges(graph, id, Graph.relationFromKey(relation), [...order]), setNode: (id, node) => graph._setNode(id, node), batch: (fn) => Graph.batch(graph, fn), release: (ids) => void Graph.release(graph, ids), outgoing: (id) => Graph.getInternal(graph) ._model.outgoing(id) .map(({ source, target, type }) => ({ source, target, relation: type })), constructNode: (node) => graph._constructNode(node), dispose: release, }; }; /** * Creates a new GraphBuilder instance. */ export const make = (params?: GraphBuilderProps): GraphBuilder => new GraphBuilder(params); /** * Call from the handler of a user action, before its writes: graph updates those writes cause flush * before the next paint instead of waiting out the frame budget. */ export const flushBeforePaint = (builder: GraphBuilder): void => { const budget = builder._frameBudget(); if (!budget) { return; } budget.flushBeforePaint(); // A connector an exhausted budget left dirty is not reported dirty again, so flush it under the grant too. builder._scheduleDirtyFlush(true); }; /** * Creates a GraphBuilder from a serialized pickle string. */ export const from = (pickle?: string, registry?: Registry.AtomRegistry, urlGrammar?: UrlGrammarProps): GraphBuilder => { if (!pickle) { return make({ registry, urlGrammar }); } const { nodes, edges } = JSON.parse(pickle); return make({ nodes, edges, registry, urlGrammar }); }; // The expansion lifecycle is the generic engine's; the app layer only specializes the vocabulary. // Named (not namespace) re-export: this module already exports its own `GraphBuilder` class above. export { addExtension, destroy, explore, flush, release, removeExtension, setRetention, } from '@dxos/graph/GraphBuilder'; /** * Flatten arbitrarily nested extension groups into a single list. Pinned to the app extension type, * which the generic signature cannot infer from a recursively nested argument. */ export const flattenExtensions = (extensions: BuilderExtensions, acc: BuilderExtension[] = []): BuilderExtension[] => Builder.flattenExtensions(extensions, acc); // // Extension Creation // /** * A graph builder extension is used to add nodes to the graph. * * @param params.id The unique id of the extension. * @param params.relation The relation the graph is being expanded from the existing node. * @param params.position Affects the order the extensions are processed in. * @param params.url URL binding for the nodes this extension produces (key + resolution); see {@link UrlBinding}. * @param params.connector A function to add nodes to the graph based on a connection to an existing node. * @param params.actions A function to add actions to the graph based on a connection to an existing node. * @param params.actionGroups A function to add action groups to the graph based on a connection to an existing node. */ export type CreateExtensionRawOptions = { id: [DXN.Path] extends [never] ? `Invalid id "${Id}": final segment must be camelCase — letters and digits, starting with a letter` : Id; relation?: Node.RelationInput; position?: Position.Position; url?: UrlBinding; connector?: ConnectorExtension; actions?: ActionsExtension; actionGroups?: ActionGroupsExtension; }; /** * Create a graph builder extension (low-level API that works directly with Atoms). */ export const createExtensionRaw = ( extension: CreateExtensionRawOptions, ): BuilderExtension[] => { const { id, position, relation = 'child', url, connector: _connector, actions: _actions, actionGroups: _actionGroups, } = extension; if (!DXN.isValidPath(id)) { log.warn( 'dropping graph extension with invalid id; the final segment must be camelCase — letters and digits, starting with a letter', { id, }, ); return []; } const normalizedRelation = normalizeRelation(relation); const getId = (key: string) => `${id}/${key}`; const connector = _connector && Atom.family((node: Atom.Atom>) => _connector(node).pipe(withLabel(`graph-builder:_connector:${id}`)), ); const actionGroups = _actionGroups && Atom.family((node: Atom.Atom>) => _actionGroups(node).pipe(withLabel(`graph-builder:_actionGroups:${id}`)), ); const actions = _actions && Atom.family((node: Atom.Atom>) => _actions(node).pipe(withLabel(`graph-builder:_actions:${id}`)), ); const extensions = [ connector ? ({ id: getId('connector'), position, relation: normalizedRelation, meta: url, connector: Atom.family((node) => Atom.make((get) => { try { return get(connector(node)); } catch (error) { log.warn('Error in connector', { id: getId('connector'), node, error }); return []; } }).pipe(withLabel(`graph-builder:connector:${id}`)), ), } satisfies BuilderExtension) : undefined, actionGroups ? ({ id: getId('actionGroups'), position, relation: Node.action, connector: Atom.family((node) => Atom.make((get) => { try { return get(actionGroups(node)).map((arg) => ({ ...arg, data: Node.actionGroupSymbol, type: Node.ActionGroupType, })); } catch (error) { log.warn('Error in actionGroups', { id: getId('actionGroups'), node, error }); return []; } }).pipe(withLabel(`graph-builder:connector:actionGroups:${id}`)), ), } satisfies BuilderExtension) : undefined, actions ? ({ id: getId('actions'), position, relation: Node.action, connector: Atom.family((node) => Atom.make((get) => { try { return get(actions(node)).map((arg) => ({ ...arg, type: Node.ActionType })); } catch (error) { log.warn('Error in actions', { id: getId('actions'), node, error }); return []; } }).pipe(withLabel(`graph-builder:connector:actions:${id}`)), ), } satisfies BuilderExtension) : undefined, ].filter(isNonNullable); // A declaration-only extension: a `url` binding with no connector/actions (e.g. the workspace anchor, // which registers a key for the parser/serializer but produces no nodes of its own). Emit it so the // key table sees the binding; it has no connector so it never runs. if (extensions.length === 0 && url) { return [{ id, position, relation: normalizedRelation, meta: url } satisfies BuilderExtension]; } return extensions; }; /** * Options for creating a graph builder extension with simplified API. * All callbacks must return Effects for dependency injection. * Effects may defect — defects are caught, logged, and the extension returns empty results. * Use Effect.orDie on any failable effects inside callbacks. */ export type CreateExtensionOptions = { id: [DXN.Path] extends [never] ? `Invalid id "${Id}": final segment must be camelCase — letters and digits, starting with a letter` : Id; match: (node: Node.Node, get: Atom.AtomContext) => Option.Option; actions?: ( matched: TMatched, get: Atom.AtomContext, ) => Effect.Effect, any>, 'type'>[], never, R>; /** Contribute dropdown action groups (each with nested `actions`) to the matched node; the group's * `type`/`data` are set automatically, so returning `Node.makeActionGroup(...)` output is fine. */ actionGroups?: ( matched: TMatched, get: Atom.AtomContext, ) => Effect.Effect, 'type' | 'data'>[], never, R>; connector?: (matched: TMatched, get: Atom.AtomContext) => Effect.Effect[], never, R>; relation?: Node.RelationInput; position?: Position.Position; /** URL binding for the nodes this extension produces (key + resolution); see {@link UrlBinding}. */ url?: UrlBinding; }; /** Marks an extension body's first run; which bodies ran is not derivable from the extension list. */ const markBodyRun = (ran: Set, extensionId: string, kind: string): void => { if (ran.has(kind) || typeof performance === 'undefined') { return; } ran.add(kind); performance.mark(`graph-body:${kind}:${extensionId}`); }; /** * Run an Effect synchronously with the provided context. * Defects are caught, logged, and the fallback value is returned. * @internal */ const runEffectSyncWithFallback = ( effect: Effect.Effect, context: Context.Context, extensionId: string, fallback: T, ): T => { return Effect.runSync( effect.pipe( Effect.provide(context), Effect.catchDefect((defect) => { log.warn('Extension failed', { extension: extensionId, defect }); return Effect.succeed(fallback); }), ), ); }; /** * Create a graph builder extension with simplified API. * Returns an Effect to allow callbacks to access services via dependency injection. */ export const createExtension = ( options: CreateExtensionOptions, ): Effect.Effect => Effect.map(Effect.context(), (context) => { const { id, match, actions, actionGroups, connector, relation, position, url } = options; const bodiesRun = new Set(); const connectorExtension = connector ? createConnectorWithRuntime(id, match, connector, context, bodiesRun) : undefined; const actionsExtension = actions ? (node: Atom.Atom>) => Atom.make((get) => Function.pipe( get(node), Option.flatMap((matchedNode) => match(matchedNode, get)), Option.map((matched) => { markBodyRun(bodiesRun, id, 'actions'); return runEffectSyncWithFallback(actions(matched, get), context, id, []).map((action) => ({ ...action, // Attach captured context for action execution. _actionContext: context, })); }), Option.getOrElse(() => []), ), ) : undefined; const actionGroupsExtension = actionGroups ? (node: Atom.Atom>) => Atom.make((get) => Function.pipe( get(node), Option.flatMap((matchedNode) => match(matchedNode, get)), Option.map((matched) => { markBodyRun(bodiesRun, id, 'actionGroups'); return runEffectSyncWithFallback(actionGroups(matched, get), context, id, []).map((group) => ({ ...group, // Attach captured context to the group's child actions so they execute with the // extension's services (e.g. Capability.Service) even without an explicit runner. actions: group.actions?.map((action) => ({ ...action, _actionContext: context })), })); }), Option.getOrElse(() => []), ), ) : undefined; return createExtensionRaw({ id, relation, position, url, connector: connectorExtension, actions: actionsExtension, actionGroups: actionGroupsExtension, }); }); /** * Create a connector extension from a matcher and factory function. * The factory's data type is inferred from the matcher's return type. */ export const createConnector = ( matcher: (node: Node.Node, get: Atom.AtomContext) => Option.Option, factory: (data: TData, get: Atom.AtomContext) => Node.NodeArg[], ): ConnectorExtension => Builder.createConnector(matcher, factory); /** * Create a connector extension from a matcher and factory function with Effect support. * The factory must return an Effect. Errors are caught and logged. * @internal */ const createConnectorWithRuntime = ( extensionId: string, matcher: (node: Node.Node, get: Atom.AtomContext) => Option.Option, factory: (data: TData, get: Atom.AtomContext) => Effect.Effect[], never, R>, context: Context.Context, bodiesRun: Set, ): ConnectorExtension => Builder.createConnector(matcher, (data, get) => { markBodyRun(bodiesRun, extensionId, 'connector'); return runEffectSyncWithFallback(factory(data, get), context, extensionId, []); }); /** * Options for creating a type-based extension. * All callbacks must return Effects for dependency injection. * Effects may fail - errors are caught, logged, and the extension returns empty results. */ export type CreateTypeExtensionOptions< T extends Type.AnyEntity = Type.AnyEntity, R = never, Id extends string = string, > = { id: [DXN.Path] extends [never] ? `Invalid id "${Id}": final segment must be camelCase — letters and digits, starting with a letter` : Id; type: T; actions?: ( object: Type.InstanceType, get: Atom.AtomContext, ) => Effect.Effect>, 'type'>[], never, R>; actionGroups?: ( object: Type.InstanceType, get: Atom.AtomContext, ) => Effect.Effect, 'type' | 'data'>[], never, R>; connector?: (object: Type.InstanceType, get: Atom.AtomContext) => Effect.Effect[], never, R>; relation?: Node.RelationInput; position?: Position.Position; }; /** * Create an extension that matches nodes by schema type. * The entity type is inferred from the schema type and works for both object and relation schemas. * Returns an Effect to allow callbacks to access services via dependency injection. */ export const createTypeExtension = ( options: CreateTypeExtensionOptions, ): Effect.Effect => { const { id, type, actions, actionGroups, connector, relation, position } = options; // `string` for the id: this forwards an already-validated value, so re-checking it here would // reject the `Id` type parameter's error branch rather than the caller's literal. return createExtension, R, string>({ id, match: (node) => (Entity.instanceOf(type, node.data) ? Option.some(node.data) : Option.none()), actions, actionGroups, connector, relation, position, }); };