Skip to main content

Node spec toolkit

@dagr/vdsl is the layer where your node graph stops being a graph and starts being a language: what kinds of node exist, what ports they have, what counts as a valid configuration for one, and which pairs of ports may be joined. Visual languages is the design brief for the milestone. This page is what exists today: the adapter interface, the registry that resolves a node to a spec, and the validation a proposed connection is put through.

Dagr defines that interface and nothing behind it. There is no built-in node kind, no opinion about what a source or a transform is, and no config schema format of Dagr's invention. A kind is whatever you say it is, and its configuration is checked by a function you supply.

Usage

import { Graph } from '@dagr/graph';
import { defineRegistry } from '@dagr/vdsl';

const registry = defineRegistry({
source: { ports: [{ id: 'out', direction: 'out' }] },
filter: {
ports: [
{ id: 'in', direction: 'in', maxEdges: 1 },
{ id: 'out', direction: 'out' },
],
checkConfig: (attrs) =>
typeof attrs.threshold === 'number' ? [] : ['threshold must be a number'],
},
sink: { ports: [{ id: 'in', direction: 'in' }] },
});

const graph = new Graph();
const filter = graph.addNode(registry.nodeInit('filter', { attrs: { threshold: 0.5 } }));

registry.resolve(filter).kind; // 'filter'
registry.checkConfig(filter); // []
registry.port('filter', 'in'); // { id: 'in', direction: 'in', maxEdges: 1 }

The kinds are the keys

defineRegistry takes an object literal whose keys are your kinds, and infers the union of them once, here. Everything the registry hands back is typed against that union, so a hover or drag callback given a NodeSpec<'source' | 'filter' | 'sink'> can switch on kind and have the compiler tell it when a case is missing.

That is why the entry point is a factory taking a literal, rather than a predicate you write that reads a node and returns a spec. A node's attributes are Readonly<Partial<A>>, because any attribute can be absent, so attrs.kind is string | undefined however carefully you typed your graph. A predicate reading it erases your kind union at the boundary and every callback downstream lands on a cast.

Reading the attribute still has to happen somewhere, and it happens inside the registry, once, guarded by a real membership test:

const fromTheUrlBar: string = readSomewhere();

if (registry.has(fromTheUrlBar)) {
registry.get(fromTheUrlBar); // narrowed to your kind union
}

has is the one door from string into the union, and it answers by looking in a Map rather than with in, so 'toString' is not a kind.

Resolving a node

Three methods read a node, and they differ only in what they do when the answer is not there.

MethodA node of a declared kindAnything else
kindOfthe kindundefined
tryResolvethe specundefined
resolvethe specthrows

resolve throws two different errors, because the two failures have different causes and different fixes. NodeKindMissingError means the node does not legibly declare a kind: the attribute is absent, or it holds something that is not a string. UnknownNodeKindError means it named a kind this registry was never given, and it carries the kinds that were, which is the list a consumer wants in the message.

The attribute read is kind by default and configurable, because a consumer with an existing attribute vocabulary should not have to rename it:

const registry = defineRegistry({ source: {} }, { kindKey: 'type' });

Ports and arity

A PortSpec is what every node of a kind is promised to have, where @dagr/graph's Port is what one node does have. They differ by maxEdges, which is a rule about a port rather than a property of one, and which the graph model deliberately does not enforce: Graph permits any topology by design.

maxEdges is a cap rather than the usual 'single' | 'multiple' word. A number is the general case and the word is its two useful values, so nothing is lost, and a union declared here is a union your exhaustive switch breaks on when a third case arrives. Absent means unbounded, rather than Infinity, because Infinity does not survive JSON.stringify and a spec you cannot serialise is a spec you cannot ship a fixture of.

defineRegistry refuses a maxEdges that is not a positive integer, along with an empty kind, an empty port id, an empty type token, and a port id declared twice in one kind. It refuses at define time rather than reporting at use time, because a registry is built once from a literal, usually at module scope, and a bad spec is a bug in your source rather than in your data. The same port id in two different kinds is fine: port ids are unique within a node, not across a graph.

registry.port(kind, portId) returns undefined for a port the kind does not declare, and throws UnknownNodeKindError for a kind the registry does not hold, which is what get does and for the same reason: an undeclared kind has no ports for a port to be absent from. The compiler stops both being called that way, and JavaScript reaches them with any string at all.

A spec says what the rule is, and registry.checkConnection is where a proposed edge meets it.

Connecting two ports

A port may also carry a type, which is a token this package stores, hands to your own rule, and never interprets:

import { defineRegistry, sameType } from '@dagr/vdsl';

const registry = defineRegistry(
{
source: { ports: [{ id: 'out', direction: 'out', type: 'number' }] },
filter: {
ports: [
{ id: 'in', direction: 'in', maxEdges: 1, type: 'number' },
{ id: 'out', direction: 'out', type: 'number' },
],
canConnect: sameType,
},
},
{ rejectCycles: true },
);

registry.checkConnection(graph, {
source: 'a',
sourcePort: 'out',
target: 'b',
targetPort: 'in',
});
// { ok: true }, or { ok: false, code, reason }

Dagr never compares two tokens itself. The obvious rule, equal tokens connect, is wrong for every language with a subtype relation, an any, or a coercion, and this package has no way to know which of those you have. sameType is that rule written out as a value, so you name it when you want it. A port declaring no token is untyped and has no opinion, so sameType refuses a pair only when both ends name a token and the two differ.

canConnect is your own rule and it is asked at BOTH ends, source first, because a rule about what may arrive at a port belongs to the kind declaring the port and a rule about what may leave one belongs to the kind at the other end just as much. It returns nothing when the pair is fine and the sentence to put in front of a user when it is not. That is one string rather than checkConfig's list because a connection is a decision and a config is a report: a drag stops at the first reason a drop is refused, where a config panel shows everything wrong at once.

The result is a decision too. ok is what a filter reads, code is what a caller branches on, and reason is what it shows:

codewhat it means
no-such-portthe kind declares no port of that id
wrong-directionan in port offered as a source, or an out as a target
incompatiblea canConnect at one end said no, and reason is its words
port-fullthe port is already carrying its maxEdges
would-cyclethe edge would close a cycle, in a registry that refuses them

maxEdges caps the edges AT a port and not the edges through it in one direction, which is only visible on an inout port. That is the graph model's own reading: Graph.removePort refuses a port with users and it counts a user on either side.

Cycle rejection is a policy your adapter declares, { rejectCycles: true }, and never a default. Graph permits cycles by design and a feedback loop is the point of half the languages this toolkit exists for, so a toolkit refusing one out of the box would be wrong for them and silent about it. The question a proposed edge asks is source === target || graph.canReach(target, source), one walk over the subgraph the target reaches. Nothing is added to answer it, so no patch is emitted and no undo stack learns about a question.

The end that does not exist yet

checkConnection answers for an edge between two nodes the graph holds. A node the graph does not hold, or one carrying a kind the registry never declared, is a bug in your own data and throws, exactly as resolve does: a refusal is about the proposal and an error is about the graph.

For a drag aimed at a node you have not created yet, ask checkPorts instead. It takes two kinds and two port ids, no graph, and answers the port, direction and canConnect questions. The two it drops are both vacuous for a node about to be created: a node with no edges occupies no port and can reach nothing, so it can neither fill a cap nor close a cycle.

registry.checkPorts({ kind: 'source', portId: 'out' }, { kind: 'filter', portId: 'in' });

That is also why canConnect is handed no graph and no node ids. It has to be answerable in exactly the case where there is nothing to read.

canConnect is not mirrored onto the NodeSpec the registry hands back, where checkConfig is. A spec is what one kind promises about itself and a config check is a rule about one node, but a connection rule is a rule about a pair, so there is no one kind for it to belong to. checkPorts is the door, and it asks both ends.

Config is yours

checkConfig is handed the node's whole attribute bag and returns a list of problems. An empty list is a valid config, and every string is something to put in front of a user.

const registry = defineRegistry({
filter: {
checkConfig: (attrs) => {
const issues: string[] = [];
if (typeof attrs.threshold !== 'number') issues.push('threshold must be a number');
if (attrs.mode !== 'high' && attrs.mode !== 'low') issues.push('mode must be high or low');
return issues;
},
},
});

The whole bag, rather than a config sub-object, because which keys are configuration is your question and not Dagr's: deciding it here would be defining the ontology this package exists not to define. Strings, rather than a structured issue type, for the same reason at one remove: a structured form is a schema format of Dagr's invention by another name, and you already have one. This is where zod's issues, valibot's, or a hand-written check hands its own message across.

registry.checkConfig(node) resolves the node first, so a node of an undeclared kind throws rather than passing validation by having no validator. A kind that declared no check reports nothing.

Building a node from a kind

nodeInit returns a NodeInit for graph.addNode: the ports the kind declares, the kind attribute, and whatever you add.

type NodeAttrs = { kind: 'source' | 'filter' | 'sink'; label: string };

const graph = new Graph<NodeAttrs>();
const node = graph.addNode(registry.nodeInit('filter', { attrs: { label: 'Threshold' } }));

The attribute type comes from you, inferred from the graph the init is handed to, so label is checked against your own type. Declaring the kind key in that type (kind: 'source' | 'filter' | 'sink' above) is worth doing and is not required: the registry writes the attribute either way, because Graph stores what it is given.

Two things it will not do. The kind attribute is written last, so your attrs cannot mislabel the node they are building. And ports are not takeable from the caller, because the spec is what says which ports a kind has, and a node that quietly gained one would resolve to a spec that does not describe it.

Errors

Every error extends DagrVdslError and carries a code, so one instanceof catches the family and a switch over code stays exhaustive. isDagrVdslError narrows a caught value to the closed union of the three.

ClasscodeThrown when
InvalidSpecErrorINVALID_SPECa declared kind is one the toolkit could not act on
NodeKindMissingErrorNODE_KIND_MISSINGa node declares no legible kind
UnknownNodeKindErrorUNKNOWN_NODE_KINDa node names a kind this registry does not hold

This is a separate family from @dagr/graph's rather than a subclass of it, on that package's own instruction: each package keeps its own root, its own code union and its own predicate, so each one's exhaustive switch stays exhaustive over its own errors.

Not here yet

  • Drag-to-connect (M6.3), on top of the interaction hooks and GPU picking. That is the task where this package first needs React, which is why @dagr/react is not a peer dependency yet.
  • Subgraph nodes and drill-down (M6.4), on the containment M5.5 reserves in the graph model.
  • Collapse and expand (M6.5), and two reference languages built on the toolkit (M6.6), which is the cheapest available test of whether any of this generalises past one consumer.

There is also no per-kind payload of your own on a NodeSpec: no label, no colour, no category. That is a real want and the shape it should take is decided by what M6.3's callbacks actually need to read, so it waits for a consumer to ask, the way @dagr/graph keeps traversal.ts unexported and @dagr/layout keeps every stage but defaultStages internal. Until then, registry.kinds is typed and exhaustive, so a Record<Kind, YourPayload> of your own is checked for completeness by the compiler.